﻿////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// <copyright>Copyright 2008-2011 Andreas Huber Doenni</copyright>
// Distributed under the Microsoft Public License (Ms-PL).
// See accompanying file License.txt or copy at http://mews.codeplex.com/license.
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

namespace Mews.Application
{
    using System;
    using System.Diagnostics.CodeAnalysis;
    using System.Drawing;
    using System.Drawing.Imaging;
    using System.Globalization;
    using System.IO;
    using System.Linq;
    using System.Runtime.InteropServices;
    using System.Text;
    using System.Xml;
    using System.Xml.Linq;

    using Mews.Mime;

    /// <summary>Builds a HTML representation of a given message.</summary>
    internal sealed class HtmlConverter : MessageVisitor
    {
        private const int SpacesPerIndent = 2;
        private static readonly XNamespace xhtml = "http://www.w3.org/1999/xhtml";
        private static readonly string cssContent;
        private static readonly char[] quoteChar = new char[] { '>' };
        private readonly string tempDirectory;
        private readonly XElement contentTypeElement;
        private readonly XElement titleElement;
        private readonly XElement headerElement;
        private readonly XElement attachmentsElement;
        private readonly XElement bodyElement;
        private readonly XDocument document;
        private bool tempFilesCreated;
        private bool hasAttachments;

        /// <summary>See <see cref="MessageVisitor.OnVisit(ApplicationBody)"/>.</summary>
        public sealed override void OnVisit(ApplicationBody visitable)
        {
            using (var stream = this.CreateBodyFileStream(visitable, ".bin"))
            {
                var contentBytes = visitable.GetContent();
                stream.Write(contentBytes, 0, contentBytes.Length);
                bool isAttachment = IsAttachment(visitable);

                if (isAttachment)
                {
                    this.AddAttachment(CreateLinkElement(stream.Name, isAttachment));
                }
                else
                {
                    this.AddInline(CreateLinkElement(stream.Name, isAttachment));
                }
            }
        }

        /// <summary>See <see cref="MessageVisitor.OnVisit(ImageBody)"/>.</summary>
        public sealed override void OnVisit(ImageBody visitable)
        {
            using (var stream = this.CreateBodyFileStream(visitable, "." + visitable.Subtype))
            {
                byte[] imageBytes = visitable.GetContentBytes();
                stream.Write(imageBytes, 0, imageBytes.Length);

                if (IsAttachment(visitable))
                {
                    this.AddAttachment(CreateLinkElement(stream.Name, true));
                }
                else
                {
                    this.AddInline(CreateParagraphElement(0, CreateImageElement(stream.Name, null, null)));
                }
            }
        }

        /// <summary>See <see cref="MessageVisitor.OnVisit(TextBody)"/>.</summary>
        public sealed override void OnVisit(TextBody visitable)
        {
            if (IsAttachment(visitable))
            {
                using (var stream = this.CreateBodyFileStream(visitable, ".txt"))
                using (var writer = new StreamWriter(stream))
                {
                    writer.Write(visitable.Content);
                    this.AddAttachment(CreateLinkElement(stream.Name, true));
                }
            }
            else
            {
                string indent = new string(' ', SpacesPerIndent);

                using (var reader = new StringReader(visitable.Content))
                {
                    string line;
                    var previousQuoteDepth = int.MinValue;
                    XElement paragraph = null;
                    string lineBreak = null;

                    while ((line = reader.ReadLine()) != null)
                    {
                        var trimmed = line.TrimStart(quoteChar);
                        var quoteDepth = line.Length - trimmed.Length;

                        if (previousQuoteDepth != quoteDepth)
                        {
                            previousQuoteDepth = quoteDepth;
                            paragraph = CreateParagraphElement(quoteDepth);
                            this.AddInline(paragraph);
                            lineBreak =
                                Environment.NewLine + new string(' ', paragraph.Ancestors().Count() * SpacesPerIndent);
                            paragraph.Add(lineBreak);
                        }

                        paragraph.Add(indent);
                        paragraph.Add(ReplaceLeadingWhiteSpace(trimmed));
                        paragraph.Add(new XElement(xhtml + "br"));
                        paragraph.Add(lineBreak);
                    }
                }
            }
        }

        /// <summary>See <see cref="MessageVisitor.OnVisit(MessageBody)"/>.</summary>
        public sealed override void OnVisit(MessageBody visitable)
        {
        }

        /// <summary>See <see cref="MessageVisitor.OnVisit(MultipartBody)"/>.</summary>
        public sealed override void OnVisit(MultipartBody visitable)
        {
            if ((visitable.Subtype == MultipartSubtypeNames.Alternative) && (visitable.Parts.Count > 0))
            {
                // The first part of an alternative multipart should be the least "complicated"
                this.OnVisit(visitable.Parts[0]);
            }
            else
            {
                base.OnVisit(visitable);
            }
        }

        /// <summary>See <see cref="MessageVisitor.OnVisit(BodyPart)"/>.</summary>
        public sealed override void OnVisit(BodyPart visitable)
        {
            base.OnVisit(visitable);
        }

        /// <summary>See <see cref="MessageVisitor.OnVisit(Message)"/>.</summary>
        public sealed override void OnVisit(Message visitable)
        {
            this.titleElement.Add(visitable.Subject);

            var newsgroupsField = (UnstructuredHeaderField)visitable.Fields["Newsgroups"].FirstOrDefault();
            var groups = CreateDivElement("groups", newsgroupsField == null ? (string)null : newsgroupsField.Body);
            var dateBody = visitable.Date;
            var sent = CreateDivElement(
                "sent",
                dateBody == null ? (string)null : dateBody.Value.ToLocalTime().ToString("f", CultureInfo.CurrentCulture));
            var fromDisplayName = (visitable.FromField == null) || (visitable.FromField.Addresses.Count == 0) ?
                null : visitable.FromField.Addresses[0].ToString();
            var from = CreateDivElement("from", fromDisplayName);
            var subject = CreateDivElement("subject", visitable.Subject);

            this.headerElement.Add(groups, sent, from, subject);
            base.OnVisit(visitable);
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        internal static string ToHtml(Message message, string tempDirectory, out bool hasAttachments)
        {
            var visitor = new HtmlConverter(tempDirectory);
            message.Accept(visitor);
            hasAttachments = visitor.hasAttachments;

            if (visitor.tempFilesCreated)
            {
                // If temp files were created, the resulting html might also contain links that the user can click and
                // later click "Back" on the context menu. Unfortunately, the WebBrowser control shows a blank page when
                // a user navigates back to html that has been set with WebBrowser.DocumentText. Therefore, as soon as a
                // document contains links we need to store that document in a temp file and let the browser load that
                // insead of setting DocumentText
                string path = Path.Combine(tempDirectory, GetTempFileName() + ".html");

                using (var writer = File.CreateText(path))
                {
                    visitor.WriteTo(writer);
                }

                return "file://" + path.Replace('\\', '/');
            }
            else
            {
                using (var writer = new StringWriter(new StringBuilder(8192), CultureInfo.InvariantCulture))
                {
                    visitor.WriteTo(writer);
                    return writer.GetStringBuilder().ToString();
                }
            }
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        private HtmlConverter(string tempDirectory)
        {
            this.tempDirectory = tempDirectory;
            this.contentTypeElement = new XElement(xhtml + "meta", new XAttribute("http-equiv", "Content-Type"));
            this.titleElement = new XElement(xhtml + "title");
            var styleElement = new XElement(
                xhtml + "style", new XAttribute("type", "text/css"), new XComment(Environment.NewLine + cssContent));
            this.headerElement = CreateDivElement("header");
            this.attachmentsElement = CreateDivElement("attachments");
            this.bodyElement = CreateDivElement("body");
            this.document = new XDocument(new XElement(
                xhtml + "html",
                new XElement(xhtml + "head", this.contentTypeElement, this.titleElement, styleElement),
                new XElement(xhtml + "body", this.headerElement, this.attachmentsElement, this.bodyElement)));
        }

        private FileStream CreateBodyFileStream(Body body, string extensionFallback)
        {
            Directory.CreateDirectory(this.tempDirectory);
            this.tempFilesCreated = true;
            string fileName = body.Disposition != null ? body.Disposition.FileName : null;
            string extension = fileName == null ? extensionFallback : Path.GetExtension(fileName);
            bool lastTry = false;

            while (true)
            {
                if (fileName == null)
                {
                    fileName = GetTempFileName() + extension;
                    lastTry = true;
                }
                else
                {
                    // This ensures that file names like "\..\..\SomeFile.txt" cannot be abused by malware to save stuff
                    // in unwanted locations
                    fileName = Path.GetFileName(fileName);
                }

                string path = Path.Combine(this.tempDirectory, fileName);

                if (!File.Exists(path))
                {
                    try
                    {
                        return File.Create(path, 32768);
                    }
                    catch (Exception ex)
                    {
                        if (lastTry || !(ex is ArgumentException || ex is IOException ||
                            ex is NotSupportedException || ex is UnauthorizedAccessException))
                        {
                            throw;
                        }
                    }
                }

                fileName = null;
            }
        }

        private void AddAttachment(XElement attachment)
        {
            this.hasAttachments = true;
            this.attachmentsElement.Add(GetRoot(attachment));
        }

        private void AddInline(XElement inline)
        {
            this.bodyElement.Add(GetRoot(inline));
        }

        private void WriteTo(TextWriter textWriter)
        {
            textWriter.Write("<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\r\n" +
                "  \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">\r\n");
            this.contentTypeElement.Add(
                new XAttribute("content", "application/xhtml+xml; charset=" + textWriter.Encoding.WebName));
            RemoveIfHasNoElements(this.attachmentsElement);

            var settings = new XmlWriterSettings()
            {
                Indent = true,
                OmitXmlDeclaration = true
            };

            using (var xmlWriter = XmlWriter.Create(textWriter, settings))
            {
                this.document.Save(xmlWriter);
            }
        }

        /// <summary>Initializes static members of the <see cref="HtmlConverter"/> class.</summary>
        [SuppressMessage("Microsoft.Performance", "CA1810:InitializeReferenceTypeStaticFieldsInline", Justification = "There's no way around a static ctor.")]
        static HtmlConverter()
        {
            using (var resourceReader = new StreamReader(
                typeof(HtmlConverter).Assembly.GetManifestResourceStream(
                    typeof(HtmlConverter).Namespace + ".Css.css")))
            {
                cssContent = resourceReader.ReadToEnd()
                    .Replace("BAKGND", ToHtmlColor(SystemColors.Control))
                    .Replace("BORDER", ToHtmlColor(SystemColors.InactiveCaption))
                    .Replace("FORGND", ToHtmlColor(SystemColors.MenuText));
            }
        }

        private static XElement GetRoot(XElement element)
        {
            while (element.Parent != null)
            {
                element = element.Parent;
            }

            return element;
        }

        private static bool IsAttachment(Body body)
        {
            return (body.Disposition != null) && (body.Disposition.Kind == DispositionKind.Attachment);
        }

        private static string ReplaceLeadingWhiteSpace(string line)
        {
            int index;

            for (index = 0; (index < line.Length) && Char.IsWhiteSpace(line[index]); ++index)
            {
            }

            return (index == 0) ? line : new string('\u00A0', index) + line.Substring(index);
        }

        private static XElement CreateLinkElement(string path, bool isAttachment)
        {
            string fileName = Path.GetFileName(path);
            string iconPath = GetIconPath(path, isAttachment);
            var linkElement = new XElement(
                xhtml + "a",
                isAttachment ? new XAttribute("class", "header") : null,
                new XAttribute("href", fileName),
                fileName);
            return CreateParagraphElement(0, iconPath == null ? null : CreateImageElement(iconPath, 16, 16), linkElement);
        }

        private static XElement CreateParagraphElement(int quoteDepth, params object[] content)
        {
            var paragraph = new XElement(xhtml + "p", content);
            XElement quote = paragraph;

            while (quoteDepth > 0)
            {
                quote = new XElement(xhtml + "blockquote", quote);
                --quoteDepth;
            }

            return paragraph;
        }

        private static XElement CreateImageElement(string imagePath, int? width, int? height)
        {
            string fileName = Path.GetFileName(imagePath);
            return new XElement(
                xhtml + "img",
                new XAttribute("src", fileName),
                new XAttribute("alt", fileName),
                width == null ? null : new XAttribute("width", width.ToString()),
                height == null ? null : new XAttribute("height", height.ToString()));
        }

        private static XElement CreateDivElement(string id, params object[] content)
        {
            return new XElement(xhtml + "div", new XAttribute("id", id), content);
        }

        private static void RemoveIfHasNoElements(XElement element)
        {
            if ((element.Parent != null) && !element.HasElements)
            {
                element.Remove();
            }
        }

        private static string GetTempFileName()
        {
            return Guid.NewGuid().ToString("N");
        }

        private static string ToHtmlColor(Color color)
        {
            return (color.ToArgb() & 0xFFFFFF).ToString("X6", CultureInfo.InvariantCulture);
        }

        private static string GetIconPath(string path, bool isAttachment)
        {
            const NativeMethods.SHGFI Flags =
                NativeMethods.SHGFI.SMALLICON | NativeMethods.SHGFI.USEFILEATTRIBUTES |
                NativeMethods.SHGFI.ICON | NativeMethods.SHGFI.LINKOVERLAY;
            NativeMethods.SHFILEINFO fileInfo = new NativeMethods.SHFILEINFO();

            if (NativeMethods.SHGetFileInfo(
                path, 0, ref fileInfo, (uint)Marshal.SizeOf(fileInfo), Flags) == IntPtr.Zero)
            {
                return null;
            }

            Icon icon;

            try
            {
                icon = Icon.FromHandle(fileInfo.hIcon);
            }
            finally
            {
                NativeMethods.DestroyIcon(fileInfo.hIcon);
            }

            using (icon)
            using (var bitmap = IconHelper.ConvertToBitmap(
                icon, isAttachment ? SystemColors.Control : SystemColors.Window))
            {
                string iconPath = path + ".png";
                bitmap.Save(iconPath, ImageFormat.Png);
                return iconPath;
            }
        }
    }
}
